-- vim: set autoindent noexpandtab tabstop=4 shiftwidth=4 softtabstop=-1:
--[[
	Copyright (c) 2018 James Dornan <james@catch22.com>
	`
    This file is part of MineTest.Me Auth mod.

    Auth Mod is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    Auth mod is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with Foobar.  If not, see <https://www.gnu.org/licenses/>.
	
	THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
	IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
	FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
	AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
	LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
	OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
	SOFTWARE.
--]]
---
-- Auth mod is a custom replacement for auth.txt that uses mtm.storage
-- to store data in a either a postgresql or sqlite3 database. The main
-- avantage I hope to leverage from this mod is to store auth information
-- indipentant from the backend storage, allowing a single account for
-- many servers. It's just easier to recognize people when they have the
-- same name on each server.
--
-- At this time it has only been tested with PostgreSQL, but should work
-- with sqlite3 also.
--
-- @author James Dornan james _at_ catch22.com
-- @copyright James Dornan (MIT)
-- @license MIT
-- @module auth
-- @see https://dev.minetest.net/minetest.register_authentication_handler
--

-- ldoc -f discount -p 'Minetest Auth Mod' -d <output directory> -a init.lua

-- We do not deal with singleplayer.
-- Will not support older versions of Minetest
if not minetest.settings or minetest.is_singleplayer() then
	minetest.log('info', '[MOD] auth not loading on older Minetest.')
	return
end

--- auth class table
-- @field modname          string  Name of this mod
-- @field max_name_len     int     Maximum length of name string
-- @field max_password_len int     Maximum length of name string
-- @field entries          table   mTm.storage object used to store auth entries
-- @field cache            table   Contains cached auth entries
-- @field cache_on         boolean Used to turn off cache only when debugging
-- @field default_privs    table   Contains the default privs for the server
-- @field admin_set_privs  boolean Flag that indicates if the admin has had they privs saved
-- @field gc_timer         float   Global step times for garbage connection
-- @field cache_hits       int     How many times the cache was used
-- @field database_hits    int     How the database was used
local auth = {
	name             = minetest.get_current_modname(),
	max_name_len     = 32,
	max_password_len = 512,
	entries          = mtm.storage(),
	cache            = {},
	cache_on         = true,
	default_privs    = nil,
	admin_set_privs  = false,
	gc_timer         = 0,
	gc_interval      = 60,
	cache_hits       = 0,
	database_hits    = 0,
}

--- Functions
-- @section functions

--- Get default privs from minetest.conf and convert to table
-- @return table
auth.get_default_privs = function()
	if type(auth.default_privs) == "table" then
		return auth.default_privs
	end

	auth.default_privs = minetest.string_to_privs(minetest.settings:get("default_privs"))

	return auth.default_privs or {}
end

--- Check to see if a player is the admin
-- @param  string name of player
-- @return boolean
auth.is_admin = function(name)
	return (name == minetest.settings:get("name"))
end

--- Check to see if a player has an auth entry
-- @param  string name of player
-- @return boolean
auth.exists = function(name)
	return (type(auth.get(name)) == 'table')
end

--- Get entry from cache or database
-- Get auth entry from the cache if caching is enabled. If entry not in the
-- cache then it will be selectd from the database and cached.
-- @param  string name of player
-- @return table
auth.get = function(name)
	-- Caching off return entry
	if not auth.cache_on then
		auth.database_hits = auth.database_hits + 1

		return auth.entries[name]
	end

	-- Caching on and we have it 
	if auth.cache[name] then
		auth.cache_hits = auth.cache_hits + 1

		return auth.cache[name]
	end

	-- Get entry from database
	local entry = auth.entries[name]

	-- If entry update cache
	if entry then
		auth.cache[name] = entry
	end

	auth.database_hits = auth.database_hits + 1

	return entry
end

--- Set entry data and cache
-- Creat or update an auth entry. The data table supplied can be all auth fields
-- or a subset. IF caching is enabled the the entry will be cached after a
-- successful update of the database table.
-- @param  string name of player
-- @param  table  auth entry data
-- @return boolean
auth.set = function(name, data)
	minetest.log('info', '[auth] set ' .. name)

	local entry = auth.get(name)

	if type(entry) ~= 'table' then
		entry = {}
	end
	
	-- Update entry with data
	for key, value in pairs(data) do
		entry[key] = value
	end

	-- Save to database
	auth.entries[name] = entry;

	-- Update cache
	if auth.cache_on then
		auth.cache[name] = entry;
	end
end

--- Check to see if a player name is okay
-- @param  string name of player
-- @return boolean
auth.name_ok = function(name)
	if type(name) ~= 'string' then
		minetest.log('errro', '[auth] player name not a string.')
		return false
	end

	if name:len() > auth.max_name_len then
		return false
	end

	return true
end

--- Check to see if a password string is okay
-- @param  string password
-- @return boolean
auth.password_ok = function(password)
	if type(password) ~= 'string' then
		minetest.log('error', '[auth] player password not a string.')
		return false
	end

	if password:len() > auth.max_password_len then
		return false
	end

	return true
end

--- Get auth information for a player from the cache or database
-- If entry exists in cache it will be returned and if it does
-- not(rare) then the entry will be cached as well as returned.
-- @param  string name
-- @return table
auth.get_auth = function(name)
	minetest.log('info', '[auth] get_auth ' .. name)

	if not auth.name_ok(name) then
		minetest.log('error', '[auth] name not ok ' .. name)
		return nil
	end

	if not auth.exists(name) then
		return nil
	end

	local admin      = auth.is_admin(name)
	local privileges = {}

	if not admin or auth.admin_set_privs then
		return auth.get(name)
	end

	for priv, def in pairs(core.registered_privileges) do
		privileges[priv] = true
	end
		
	auth.set_privileges(name, privileges)
	auth.admin_set_privs = true

	return auth.get(name)
end

--- Create new entry for user in the auth table
-- @param  string name
-- @param  string password
-- @param  string reason
-- @return boolean
auth.create_auth = function(name, password, reason)
	minetest.log('info', '[auth] create_auth ' .. name)

	if not auth.name_ok(name) or not auth.password_ok(name) then
		return false
	end

	if not auth.exists(name) then
		return false
	end

	auth.set(name, {
		   userid     = name,
		   password   = password,
		   privileges = auth.get_default_privs(),
		   last_login = math.floor(os.time())
	})

	return (type(auth.entries[name]) == "table")
 end

--- Create new entry for user in the auth table
-- @param  string name
 auth.delete_auth = function(name)
	minetest.log('info', '[auth] delete_auth ' .. name)

	if not auth.name_ok(name) then
		return false
	end

	if auth.get(name) then
		auth.entries[name] = nil

		if auth.cache_on then
			auth.cache[name] = nil
		end
	end

	return true
end

--- Set player password
-- @param  string name
-- @param  string password
-- @return boolean
auth.set_password = function(name, password)
	minetest.log('info', '[auth] set_password ' .. name)

	if not auth.name_ok(name) or not auth.password_ok(name) then
		minetest.log('error', '[auth] name or password not ok ' .. name)
		return false
	end

	if not auth.exists(name) then
		minetest.log(
			'info',
			'[auth] ' .. name .. ' does not have an auth entry.'
		)
		return auth.create_auth(
			name,
			password,
			"auth.set_password called without an account"
		)

	end

	-- Same as old password?
	if auth.get(name).password == password then
		return true
	end

	auth.set(name, {password = password})

	return (auth.get(name).password == password)
end

--- Set player privileges
-- @param  string name
-- @param  table  privileges
-- @return boolean
auth.set_privileges = function(name, privileges)
	minetest.log('info', '[auth] set_privileges ' .. name)

	assert(type(privileges) == 'table')

	if not auth.name_ok(name) then
		return false
	end

	if not auth.exists(name) then
		return false
	end

	-- No change?
	if minetest.serialize(auth.get(name).privileges) == minetest.serialize(privileges) then
		return true
	end

	auth.set(name, {privileges = privileges})

	-- Verify
	if minetest.serialize(auth.get(name).privileges) ~= minetest.serialize(privileges) then
		return false
	end

	minetest.notify_authentication_modified(name)

	return true
end

--- Here only for compatibility
-- @return boolean
auth.reload = function()
	return true
end

--- Update player's last login timestamp
-- @param  string name
-- @return boolean
auth.record_login = function(name)
	if not auth.name_ok(name) or not auth.exists(name) then
		return false
	end

	local last_login = math.floor(os.time())

	auth.set(name, {last_login = last_login})

	return (auth.get(name).last_login == last_login)
end

--- Import legacy auth.txt file and rename to auth.txt.import
auth.import_legacy = function()
    local file        = minetest.get_worldpath() .. '/auth.txt'
    local backup_file = file .. '.backup'
    local exists, err = io.open(file, 'r')
	local legacy      = exists ~= nil
	local cache_on    = auth.cache_on

	if exists then
		exists:close()
	end

	if not legacy then
		return
    end

	minetest.log('[auth] loading lagacy auth.txt file.')

	-- No caching
	auth.cache_on = false

	for line in io.lines(file) do
		auth.save_legacy_record(line)
	end

	-- Resume caching
	auth.cache_on = cache_on

    local ret, err os.rename(file, backup_file)
    assert(err == nil, err)

	minetest.log('info', '[auth] copied auth.txt to auth.txt.backup.')
end

--- Save a single record from the legacy auth.txt file
-- @param string line
auth.save_legacy_record = function(line)
	if not line:find(':') then
		return
	end

	local userid, password, privilege_string, last_login = unpack(line:split(':', true))

	if auth.exists(userid) then
		return
	end

	auth.set(userid, {
		userid     = userid,
		password   = password,
		privileges = minetest.string_to_privs(privilege_string),
		last_login = tonumber(last_login)
	})
end

--- Garbage collection. Remove cached auth entries for users no longer logged in.
-- This should run at least once per hour of uptime. If the player is not logged in
-- at that time their cache entry will be removed.
-- @param float dtime
auth.gc = function(dtime)
	auth.gc_timer = auth.gc_timer + dtime

	if auth.gc_timer < auth.gc_interval then
		return
	end

	-- Tracking in memory cache use vs expensive database use
	minetest.log(
		'[auth] Cache Hits ' ..
		auth.cache_hits ..
		' Database Hits ' ..
		auth.database_hits
	)

	auth.gc_timer = 0

	local connected = {}
	local count = 0 -- Needed for array count. It's a lua thing.

	-- get list of connected names
	for key, value in ipairs(minetest.get_connected_players()) do
		connected[value:get_player_name()] = true
		count = count + 1
	end

	-- Remove unused cache
	for name, entry in pairs(auth.cache) do
		if not connected[name] then
			minetest.log('verbose', '[auth] gc removing cache for ' .. name)
			auth.cache[name] = nil
		end
	end
end

--- Initialize the auth mod
-- @param _G global scope
auth.init = function(_G)
	minetest.log('[auth] Mod Initializing. License GPLv3')

	auth.import_legacy()

	minetest.register_authentication_handler(auth)
	minetest.register_globalstep(auth.gc)

	_G.auth = auth

	minetest.log('[auth] Mod loaded.')
end

auth.init(_G)
